Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 9acdf5309f8f0ab96b30d6505bdac5e93a3bb72c


Parents : 99378c9
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-04-14T06:41:01-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-04-14T11:41:01Z

refactor: modern APIs β€” Koin 4.2, CMP 1.11, Ktor resilience, Room @Upsert, injected dispatchers (#5119)

Changes

32 files changed, 450 insertions(+), 275 deletions(-)


Diff

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0942756c02..39e6bbcc72 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -266,7 +266,6 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
- implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 70ff4858dd..e4eabbb768 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
-import kotlinx.coroutines.Dispatchers
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
+ private val dispatchers: CoroutineDispatchers,
+ private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
- val loadedItems = persistedLayerFiles.mapNotNull { file ->
- if (file.isFile) {
- val layerType =
- when (file.extension.lowercase()) {
- "kml",
- "kmz",
- -> LayerType.KML
- "geojson",
- "json",
- -> LayerType.GEOJSON
- else -> null
+ val loadedItems =
+ persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ val layerType =
+ when (file.extension.lowercase()) {
+ "kml",
+ "kmz",
+ -> LayerType.KML
+ "geojson",
+ "json",
+ -> LayerType.GEOJSON
+ else -> null
+ }
+
+ layerType?.let {
+ val uri = Uri.fromFile(file)
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = it,
+ )
}
-
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
+ } else {
+ null
}
- } else {
- null
}
- }
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@@ -550,7 +558,7 @@ class MapViewModel(
}
}
- private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
+ private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@@ -621,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(Dispatchers.IO) {
+ withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -636,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
- return withContext(Dispatchers.IO) {
+ return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
- val url = java.net.URL(uriToLoad.toString())
- java.io.BufferedInputStream(url.openStream())
+ val response = httpClient.get(uriToLoad.toString())
+ if (!response.status.isSuccess()) {
+ Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
+ return@withContext null
+ }
+ response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}

diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
index e33fb1f8c0..668dedbaaa 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
@@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
- fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
+ fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = CoroutineScope(dispatchers.io + SupervisorJob()),
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
}

diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 03549c0b3b..d86df9d602 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -48,8 +48,8 @@ import coil3.compose.setSingletonImageLoaderFactory
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
-import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider

diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 4aa27bf0ec..dd7e9d8be9 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -31,6 +31,8 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
@@ -40,6 +42,7 @@ import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
@@ -84,6 +87,15 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
+ install(plugin = HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(plugin = HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index fcdc079f28..c1e399c975 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(deviceHardware: DeviceHardwareEntity)
+ @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
+ @Upsert suspend fun insertAll(deviceHardware: List<DeviceHardwareEntity>)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity>

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
index 0a5520a070..040941a49c 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
@@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
+ @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index e11d10f50e..eb3c27b7e9 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
import androidx.room3.MapColumn
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@@ -168,8 +166,7 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow<MyNodeEntity?>
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
+ @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@@ -295,8 +292,7 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun putAll(nodes: List<NodeEntity>)
+ @Upsert suspend fun putAll(nodes: List<NodeEntity>)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
index 2e7f6c549c..fde388ce56 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
@@ -17,9 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -32,6 +31,5 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(entities: List<TracerouteNodePositionEntity>)
+ @Upsert suspend fun insertAll(entities: List<TracerouteNodePositionEntity>)
}

diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
index 94ef1c605f..9de792a84e 100644
--- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
+++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
@@ -50,7 +50,7 @@ class PreferencesDataStoreModule {
@Named("CorePreferencesDataStore")
fun providePreferencesDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalConfig> = DataStoreFactory.create(
storage =
OkioStorage(
@@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
storage =
OkioStorage(
@@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<ChannelSet> = DataStoreFactory.create(
storage =
OkioStorage(
@@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
storage =
OkioStorage(

diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
index aa81f1ac6c..3cb3cabe89 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -24,10 +24,17 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
+/**
+ * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances.
+ *
+ * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules.
+ */
+const val DATASTORE_SCOPE = "DataStoreScope"
+
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
- @Named("DataStoreScope")
+ @Named(DATASTORE_SCOPE)
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
new file mode 100644
index 0000000000..db558bedb5
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network
+
+/**
+ * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups.
+ *
+ * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on
+ * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry].
+ */
+object HttpClientDefaults {
+ /** Timeout in milliseconds for connect, request, and socket operations. */
+ const val TIMEOUT_MS = 30_000L
+
+ /** Maximum number of automatic retries on server errors (5xx). */
+ const val MAX_RETRIES = 3
+}

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
index 966569f4f9..36c26c8797 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt
@@ -20,12 +20,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
@@ -38,7 +38,9 @@ class MarkAsReadReceiver :
private val serviceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index 028030f765..5869ce94f7 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -25,11 +25,11 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koin.android.ext.android.inject
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.toRemoteExceptions
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
@@ -84,8 +84,10 @@ class MeshService : Service() {
private val router: MeshRouter by inject()
+ private val dispatchers: CoroutineDispatchers by inject()
+
private val serviceJob = Job()
- private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) }
private var isServiceInitialized = false

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
index 5965b9dddf..f4db744039 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.ServiceRepository
@@ -41,7 +41,9 @@ class ReactionReceiver :
private val serviceRepository: ServiceRepository by inject()
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchers.io) }
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 4e82a735d5..d7a9437830 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -21,11 +21,11 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
@@ -44,7 +44,9 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshServiceNotifications by inject()
- private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val dispatchers: CoroutineDispatchers by inject()
+
+ private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"

diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index d07a5afc39..44b483c916 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -55,7 +55,6 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite)
- implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)

diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
index f8b0586f46..aa47539bb7 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
@@ -27,17 +27,18 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import org.meshtastic.core.common.util.nowMillis
@Composable
actual fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
- var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
+ var value by remember { mutableLongStateOf(nowMillis) }
DisposableEffect(context) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- value = System.currentTimeMillis()
+ value = nowMillis
}
}

diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
index 22f84b217e..1652621701 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
+import org.meshtastic.core.common.util.nowMillis
-/** JVM implementation β€” returns System.currentTimeMillis() (no lifecycle-based updates on Desktop). */
-@Composable actual fun rememberTimeTickWithLifecycle(): Long = System.currentTimeMillis()
+/** JVM implementation β€” returns the current epoch millis (no lifecycle-based updates on Desktop). */
+@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 26fa16f6e2..e3c7f8b191 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -25,15 +26,22 @@ import org.meshtastic.core.repository.NotificationPrefs
import androidx.compose.ui.window.Notification as ComposeNotification
/**
- * Desktop notification manager. Registered manually in [desktopPlatformStubsModule] β€” do NOT add @Single to avoid
- * double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
+ * Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications.
+ *
+ * Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user
+ * preferences for message, node-event, and low-battery categories.
+ *
+ * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
+ * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
init {
- co.touchlab.kermit.Logger.i { "DesktopNotificationManager initialized" }
+ Logger.i { "DesktopNotificationManager initialized" }
}
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
+
+ /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
override fun dispatch(notification: Notification) {
@@ -46,9 +54,7 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Category.Service -> true
}
- co.touchlab.kermit.Logger.d {
- "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled"
- }
+ Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" }
if (!enabled) return
@@ -61,14 +67,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
}
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
- co.touchlab.kermit.Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
+ Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
}
override fun cancel(id: Int) {
- // Desktop Tray notifications cannot be cancelled once sent via TrayState
+ // Desktop tray notifications cannot be cancelled once sent via TrayState.
}
override fun cancelAll() {
- // Desktop Tray notifications cannot be cleared once sent via TrayState
+ // Desktop tray notifications cannot be cleared once sent via TrayState.
}
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 0a450c0079..25a5b8ce37 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -18,7 +18,6 @@ package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -27,22 +26,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
-import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
@@ -55,13 +54,19 @@ import coil3.memory.MemoryCache
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
+import coil3.util.DebugLogger
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
-import org.jetbrains.skia.Image
+import org.jetbrains.compose.resources.decodeToSvgPainter
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
@@ -75,33 +80,50 @@ import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop
import java.util.Locale
+import coil3.util.Logger as CoilLogger
/** Meshtastic Desktop β€” the first non-Android target for the shared KMP module graph. */
-private val LocalAppLocale = staticCompositionLocalOf { "" }
-
private const val MEMORY_CACHE_MAX_BYTES = 64L * 1024L * 1024L // 64 MiB
private const val DISK_CACHE_MAX_BYTES = 32L * 1024L * 1024L // 32 MiB
+/**
+ * Loads an SVG from JVM classpath resources and returns a [Painter].
+ *
+ * Uses the CMP 1.11 `decodeToSvgPainter` extension which replaces the deprecated `useResource`/`loadSvgPainter` pair.
+ * The SVG bytes are read from the classpath because CMP `composeResources/` only supports XML vector drawables and
+ * raster images β€” not raw SVGs. Since the desktop module is a JVM-only host shell, classpath resource access is safe.
+ */
@Composable
-private fun classpathPainterResource(path: String): Painter {
- val bitmap: ImageBitmap =
- remember(path) {
- val bytes = Thread.currentThread().contextClassLoader!!.getResourceAsStream(path)!!.readAllBytes()
- Image.makeFromEncoded(bytes).toComposeImageBitmap()
+private fun svgPainterResource(path: String, density: Density): Painter = remember(path, density) {
+ val classLoader =
+ requireNotNull(Thread.currentThread().contextClassLoader) {
+ "Missing context class loader while loading resource: $path"
}
- return remember(bitmap) { BitmapPainter(bitmap) }
+ val bytes =
+ requireNotNull(classLoader.getResourceAsStream(path)) { "Missing classpath resource: $path" }
+ .use { it.readAllBytes() }
+ bytes.decodeToSvgPainter(density)
}
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop β€” Starting" }
- val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
- val systemLocale = remember { Locale.getDefault() }
- val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
- val httpClient = remember { koinApp.koin.get<HttpClient>() }
+ remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
+ DisposableEffect(Unit) { onDispose { stopKoin() } }
+
+ val uiViewModel = koinViewModel<UIViewModel>()
+ DeepLinkHandler(args, uiViewModel)
+ MeshServiceLifecycle()
+ ThemeAndLocaleProvider(uiViewModel)
+}
+
+// ----- Deep link handling -----
+
+/** Processes deep-link URIs from CLI arguments and OS-level URI handlers. */
+@Composable
+private fun ApplicationScope.DeepLinkHandler(args: Array<String>, uiViewModel: UIViewModel) {
LaunchedEffect(args) {
args.forEach { arg ->
if (
@@ -124,14 +146,28 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
}
}
+}
+
+// ----- Mesh service lifecycle -----
- val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
+/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal. */
+@Composable
+private fun MeshServiceLifecycle() {
+ val meshServiceController = koinInject<MeshServiceOrchestrator>()
DisposableEffect(Unit) {
meshServiceController.start()
onDispose { meshServiceController.stop() }
}
+}
+
+// ----- Theme, locale, and application shell -----
- val uiPrefs = remember { koinApp.koin.get<UiPrefs>() }
+/** Resolves the user's theme/locale preferences and renders the full application UI. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.ThemeAndLocaleProvider(uiViewModel: UIViewModel) {
+ val systemLocale = remember { Locale.getDefault() }
+ val uiPrefs = koinInject<UiPrefs>()
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val localePref by uiPrefs.locale.collectAsState(initial = "")
@@ -144,25 +180,59 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
else -> isSystemInDarkTheme()
}
+ MeshtasticDesktopApp(uiViewModel, isDarkTheme)
+}
+
+// ----- Application chrome (tray, window, navigation) -----
+
+/** Composes the system tray, window, and Coil image loader. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDarkTheme: Boolean) {
var isAppVisible by remember { mutableStateOf(true) }
var isWindowReady by remember { mutableStateOf(false) }
val trayState = rememberTrayState()
- val appIcon = classpathPainterResource("icon.png")
+ val density = LocalDensity.current
+ val appIcon = svgPainterResource("tray_icon_black.svg", density)
- @Suppress("DEPRECATION")
val trayIcon =
- androidx.compose.ui.res.painterResource(
- if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg",
- )
+ svgPainterResource(if (isSystemInDarkTheme()) "tray_icon_white.svg" else "tray_icon_black.svg", density)
- val notificationManager = remember { koinApp.koin.get<DesktopNotificationManager>() }
- val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
+ val notificationManager = koinInject<DesktopNotificationManager>()
+ val desktopPrefs = koinInject<DesktopPreferencesDataSource>()
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
}
+ WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }
+
+ Tray(
+ state = trayState,
+ icon = trayIcon,
+ tooltip = "Meshtastic Desktop",
+ onAction = { isAppVisible = true },
+ menu = {
+ Item("Show Meshtastic", onClick = { isAppVisible = true })
+ Item("Quit", onClick = ::exitApplication)
+ },
+ )
+
+ if (isWindowReady && isAppVisible) {
+ MeshtasticWindow(uiViewModel, isDarkTheme, appIcon, windowState) { isAppVisible = false }
+ }
+}
+
+// ----- Window bounds persistence -----
+
+/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */
+@Composable
+private fun WindowBoundsManager(
+ desktopPrefs: DesktopPreferencesDataSource,
+ windowState: WindowState,
+ onReady: () -> Unit,
+) {
LaunchedEffect(Unit) {
val initialWidth = desktopPrefs.windowWidth.first()
val initialHeight = desktopPrefs.windowHeight.first()
@@ -177,7 +247,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
WindowPosition(Alignment.Center)
}
- isWindowReady = true
+ onReady()
snapshotFlow {
val x = if (windowState.position.isSpecified) windowState.position.x.value else Float.NaN
@@ -188,86 +258,99 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
desktopPrefs.setWindowBounds(width = bounds[0], height = bounds[1], x = bounds[2], y = bounds[3])
}
}
+}
- Tray(
- state = trayState,
- icon = trayIcon,
- tooltip = "Meshtastic Desktop",
- onAction = { isAppVisible = true },
- menu = {
- Item("Show Meshtastic", onClick = { isAppVisible = true })
- Item("Quit", onClick = ::exitApplication)
- },
- )
+// ----- Main window with keyboard shortcuts and Coil -----
- if (isWindowReady && isAppVisible) {
- val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
- val backStack = multiBackstack.activeBackStack
-
- Window(
- onCloseRequest = { isAppVisible = false },
- title = "Meshtastic Desktop",
- icon = appIcon,
- state = windowState,
- onPreviewKeyEvent = { event ->
- if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return@Window false
- when {
- event.key == Key.Q -> {
- exitApplication()
- true
- }
- event.key == Key.Comma -> {
- if (
- TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())
- ) {
- multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
- }
- true
- }
- event.key == Key.One -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
- true
- }
- event.key == Key.Two -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
- true
- }
- event.key == Key.Three -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
- true
- }
- event.key == Key.Four -> {
- multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
- true
- }
- event.key == Key.Slash -> {
- backStack.add(SettingsRoute.About)
- true
- }
- else -> false
- }
- },
- ) {
- setSingletonImageLoaderFactory { context ->
- val cacheDir = desktopDataDir() + "/image_cache_v3"
- ImageLoader.Builder(context)
- .components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
- // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
- // that show up as solid/black hardware images.
- add(SvgDecoder.Factory(renderToBitmap = true))
- }
- .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
- .diskCache {
- DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build()
- }
- .crossfade(true)
- .build()
+/** Renders the main application window with keyboard shortcuts, Coil image loading, and the Compose UI tree. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun ApplicationScope.MeshtasticWindow(
+ uiViewModel: UIViewModel,
+ isDarkTheme: Boolean,
+ appIcon: Painter,
+ windowState: WindowState,
+ onCloseRequest: () -> Unit,
+) {
+ val multiBackstack = rememberMultiBackstack(TopLevelDestination.Connections.route)
+
+ Window(
+ onCloseRequest = onCloseRequest,
+ title = "Meshtastic Desktop",
+ icon = appIcon,
+ state = windowState,
+ onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) },
+ ) {
+ CoilImageLoaderSetup()
+ AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
+ }
+}
+
+/** Configures the Coil singleton [ImageLoader] with Ktor networking, SVG decoding, and caching. */
+@Composable
+@OptIn(ExperimentalCoilApi::class)
+private fun CoilImageLoaderSetup() {
+ val httpClient = koinInject<HttpClient>()
+ val buildConfigProvider = koinInject<BuildConfigProvider>()
+
+ setSingletonImageLoaderFactory { context ->
+ val cacheDir = desktopDataDir() + "/image_cache_v3"
+ ImageLoader.Builder(context)
+ .components {
+ add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ // Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
+ // that show up as solid/black hardware images.
+ add(SvgDecoder.Factory(renderToBitmap = true))
}
+ .memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
+ .diskCache { DiskCache.Builder().directory(cacheDir.toPath()).maxSizeBytes(DISK_CACHE_MAX_BYTES).build() }
+ .logger(if (buildConfigProvider.isDebug) DebugLogger(minLevel = CoilLogger.Level.Verbose) else null)
+ .crossfade(true)
+ .build()
+ }
+}
+
+// ----- Keyboard shortcuts -----
- CompositionLocalProvider(LocalAppLocale provides localePref) {
- AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
+/** Handles Cmd-key shortcuts. Returns `true` if the event was consumed. */
+private fun handleKeyboardShortcut(
+ event: androidx.compose.ui.input.key.KeyEvent,
+ multiBackstack: MultiBackstack,
+ exitApplication: () -> Unit,
+): Boolean {
+ if (event.type != KeyEventType.KeyDown || !event.isMetaPressed) return false
+ val backStack = multiBackstack.activeBackStack
+ return when (event.key) {
+ Key.Q -> {
+ exitApplication()
+ true
+ }
+ Key.Comma -> {
+ if (TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull())) {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Settings.route)
}
+ true
+ }
+ Key.One -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Conversations.route)
+ true
+ }
+ Key.Two -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Nodes.route)
+ true
+ }
+ Key.Three -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Map.route)
+ true
+ }
+ Key.Four -> {
+ multiBackstack.navigateTopLevel(TopLevelDestination.Connections.route)
+ true
+ }
+ Key.Slash -> {
+ backStack.add(SettingsRoute.About)
+ true
}
+ else -> false
}
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
index 9af34f28da..6dd562bd4a 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/data/DesktopPreferencesDataSource.kt
@@ -21,7 +21,6 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -30,16 +29,21 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
-const val KEY_WINDOW_WIDTH = "window_width"
-const val KEY_WINDOW_HEIGHT = "window_height"
-const val KEY_WINDOW_X = "window_x"
-const val KEY_WINDOW_Y = "window_y"
-
+/**
+ * Persists and restores desktop window geometry (position and size) across application restarts.
+ *
+ * Backed by the `CorePreferencesDataStore` [DataStore] instance. Window bounds are written atomically via
+ * [setWindowBounds] and exposed as [StateFlow] properties for composable consumption.
+ */
@Single
-class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
+class DesktopPreferencesDataSource(
+ @Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>,
+ dispatchers: CoroutineDispatchers,
+) {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
val windowWidth: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_WIDTH, default = 1024f)
val windowHeight: StateFlow<Float> = dataStore.prefStateFlow(key = WINDOW_HEIGHT, default = 768f)
@@ -64,9 +68,9 @@ class DesktopPreferencesDataSource(@Named("CorePreferencesDataStore") private va
): StateFlow<T> = data.map { it[key] ?: default }.stateIn(scope = scope, started = started, initialValue = default)
companion object {
- val WINDOW_WIDTH = floatPreferencesKey(KEY_WINDOW_WIDTH)
- val WINDOW_HEIGHT = floatPreferencesKey(KEY_WINDOW_HEIGHT)
- val WINDOW_X = floatPreferencesKey(KEY_WINDOW_X)
- val WINDOW_Y = floatPreferencesKey(KEY_WINDOW_Y)
+ val WINDOW_WIDTH = floatPreferencesKey("window_width")
+ val WINDOW_HEIGHT = floatPreferencesKey("window_height")
+ val WINDOW_X = floatPreferencesKey("window_x")
+ val WINDOW_Y = floatPreferencesKey("window_y")
}
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
index 0bb5311aab..d27f6d5d9c 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopDiModule.kt
@@ -19,6 +19,10 @@ package org.meshtastic.desktop.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
+/**
+ * Koin module that component-scans the `org.meshtastic.desktop` package for annotated bindings (`@Single`, `@Factory`,
+ * `@KoinViewModel`).
+ */
@Module
@ComponentScan("org.meshtastic.desktop")
class DesktopDiModule

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 336f87b549..5b3b03f9de 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -14,11 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+@file:Suppress("ktlint:standard:no-unused-imports") // Koin KSP-generated extension functions require aliased imports
+
package org.meshtastic.desktop.di
// Generated Koin module extensions from core KMP modules
import io.ktor.client.HttpClient
import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
@@ -32,6 +36,7 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.network.service.ApiService
@@ -163,7 +168,7 @@ private fun desktopPlatformStubsModule() = module {
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
single<MeshWorkerManager> { NoopMeshWorkerManager() }
- single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get()) }
+ single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
single<MeshLocationManager> { NoopMeshLocationManager() }
single<LocationRepository> { NoopLocationRepository() }
single<MQTTRepository> { NoopMQTTRepository() }
@@ -178,6 +183,15 @@ private fun desktopPlatformStubsModule() = module {
single<HttpClient> {
HttpClient(Java) {
install(ContentNegotiation) { json(get<Json>()) }
+ install(HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
index 6b0aa1b2ad..743c2065de 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
@@ -27,7 +27,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toPath
@@ -35,10 +34,12 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.datastore.di.DATASTORE_SCOPE
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
@@ -49,10 +50,10 @@ import org.meshtastic.proto.LocalStats
private fun createPreferencesDataStore(name: String, scope: CoroutineScope): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
- return PreferenceDataStoreFactory.create(
+ return PreferenceDataStoreFactory.createWithPath(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
scope = scope,
- produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath().toFile() },
+ produceFile = { "$dir/$name.preferences_pb".toPath() },
)
}
@@ -80,16 +81,15 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
* - [Lifecycle] (`ProcessLifecycle`)
* - [BuildConfigProvider]
*/
-@Suppress("InjectDispatcher")
fun desktopPlatformModule() = module {
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
// DataStore has no close() API β€” the in-memory cache is released only when this Job is cancelled
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
- val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ single<CoroutineScope>(named(DATASTORE_SCOPE)) { CoroutineScope(get<CoroutineDispatchers>().io + SupervisorJob()) }
- includes(desktopPreferencesDataStoreModule(dataStoreScope), desktopProtoDataStoreModule(dataStoreScope))
+ includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
// -- Build config (values generated at build time by generateDesktopBuildConfig) --
single<BuildConfigProvider> {
@@ -108,30 +108,50 @@ fun desktopPlatformModule() = module {
}
/** Named [DataStore]<[Preferences]> instances for all preference domains. */
-private fun desktopPreferencesDataStoreModule(scope: CoroutineScope) = module {
- single<DataStore<Preferences>>(named("AnalyticsDataStore")) { createPreferencesDataStore("analytics", scope) }
+private fun desktopPreferencesDataStoreModule() = module {
+ single<DataStore<Preferences>>(named("AnalyticsDataStore")) {
+ createPreferencesDataStore("analytics", get(named(DATASTORE_SCOPE)))
+ }
single<DataStore<Preferences>>(named("HomoglyphEncodingDataStore")) {
- createPreferencesDataStore("homoglyph_encoding", scope)
+ createPreferencesDataStore("homoglyph_encoding", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("AppDataStore")) {
+ createPreferencesDataStore("app", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("CustomEmojiDataStore")) {
+ createPreferencesDataStore("custom_emoji", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("MapDataStore")) {
+ createPreferencesDataStore("map", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("MapConsentDataStore")) {
+ createPreferencesDataStore("map_consent", get(named(DATASTORE_SCOPE)))
}
- single<DataStore<Preferences>>(named("AppDataStore")) { createPreferencesDataStore("app", scope) }
- single<DataStore<Preferences>>(named("CustomEmojiDataStore")) { createPreferencesDataStore("custom_emoji", scope) }
- single<DataStore<Preferences>>(named("MapDataStore")) { createPreferencesDataStore("map", scope) }
- single<DataStore<Preferences>>(named("MapConsentDataStore")) { createPreferencesDataStore("map_consent", scope) }
single<DataStore<Preferences>>(named("MapTileProviderDataStore")) {
- createPreferencesDataStore("map_tile_provider", scope)
+ createPreferencesDataStore("map_tile_provider", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("MeshDataStore")) {
+ createPreferencesDataStore("mesh", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("RadioDataStore")) {
+ createPreferencesDataStore("radio", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("UiDataStore")) {
+ createPreferencesDataStore("ui", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("MeshLogDataStore")) {
+ createPreferencesDataStore("meshlog", get(named(DATASTORE_SCOPE)))
+ }
+ single<DataStore<Preferences>>(named("FilterDataStore")) {
+ createPreferencesDataStore("filter", get(named(DATASTORE_SCOPE)))
}
- single<DataStore<Preferences>>(named("MeshDataStore")) { createPreferencesDataStore("mesh", scope) }
- single<DataStore<Preferences>>(named("RadioDataStore")) { createPreferencesDataStore("radio", scope) }
- single<DataStore<Preferences>>(named("UiDataStore")) { createPreferencesDataStore("ui", scope) }
- single<DataStore<Preferences>>(named("MeshLogDataStore")) { createPreferencesDataStore("meshlog", scope) }
- single<DataStore<Preferences>>(named("FilterDataStore")) { createPreferencesDataStore("filter", scope) }
single<DataStore<Preferences>>(named("CorePreferencesDataStore")) {
- createPreferencesDataStore("core_preferences", scope)
+ createPreferencesDataStore("core_preferences", get(named(DATASTORE_SCOPE)))
}
}
/** Proto [DataStore] instances (OkioStorage-backed). */
-private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
+private fun desktopProtoDataStoreModule() = module {
val protoDir = desktopDataDir() + "/datastore"
single<DataStore<LocalConfig>>(named("CoreLocalConfigDataStore")) {
@@ -143,7 +163,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -156,7 +176,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/module_config.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -169,7 +189,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/channel_set.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
@@ -182,7 +202,7 @@ private fun desktopProtoDataStoreModule(scope: CoroutineScope) = module {
producePath = { "$protoDir/local_stats.pb".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
- scope = scope,
+ scope = get(named(DATASTORE_SCOPE)),
)
}
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index f30ecb66b9..594a62bc4d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
@@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
+import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -29,42 +30,22 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
/**
- * Registers entry providers for all top-level desktop destinations.
+ * Registers [NavKey] entry providers for every desktop destination.
*
- * Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
- * `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
- * [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
- * shared composables are wired.
+ * Each call delegates to the shared navigation graph extension exported by the corresponding feature module, keeping
+ * the desktop shell free of screen-level composable knowledge.
*/
-fun EntryProviderScope<NavKey>.desktopNavGraph(
- backStack: NavBackStack<NavKey>,
- uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel,
-) {
- // Nodes β€” real composables from feature:node
+fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>, uiViewModel: UIViewModel) {
nodesGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
)
-
- // Conversations β€” real composables from feature:messaging
contactsGraph(backStack, uiViewModel.scrollToTopEventFlow)
-
- // Map β€” placeholder for now, will be replaced with feature:map real implementation
mapGraph(backStack)
-
- // Firmware β€” in-flow destination (for example from Settings), not a top-level rail tab
firmwareGraph(backStack)
-
- // Settings β€” real composables from feature:settings
settingsGraph(backStack)
-
- // Channels
channelsGraph(backStack)
-
- // Connections β€” shared screen
connectionsGraph(backStack)
-
- // WiFi Provisioning β€” nymea-networkmanager BLE protocol
wifiProvisionGraph(backStack)
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
index a5ec5b7953..309fff7da7 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop.notification
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.Notification
@@ -29,8 +30,15 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/**
- * Desktop notifications implementation. Registered manually in [desktopPlatformStubsModule] β€” do NOT add @Single to
- * avoid double-registration with the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
+ * Desktop implementation of [MeshServiceNotifications].
+ *
+ * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through
+ * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications.
+ *
+ * Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
+ *
+ * Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
+ * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
@Suppress("TooManyFunctions")
class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications {
@@ -39,14 +47,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
}
override fun initChannels() {
- // no-op for desktop
+ // No-op: desktop has no Android notification channels.
}
- override fun updateServiceStateNotification(
- state: org.meshtastic.core.model.ConnectionState,
- telemetry: Telemetry?,
- ) {
- // We don't have a foreground service on desktop
+ override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {
+ // No-op: desktop has no foreground service notification.
}
override suspend fun updateMessageNotification(
@@ -106,16 +111,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat
)
}
- @Suppress("ktlint:standard:max-line-length")
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
- notificationManager.dispatch(
- Notification(
- title = name,
- message = alert,
- category = Notification.Category.Alert,
- contactKey = contactKey,
- ),
- )
+ val notification =
+ Notification(title = name, message = alert, category = Notification.Category.Alert, contactKey = contactKey)
+ notificationManager.dispatch(notification)
}
override fun showNewNodeSeenNotification(node: Node) {

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
index c272e7bd98..3888b0af3e 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt
@@ -18,9 +18,9 @@ package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
@@ -36,8 +36,9 @@ import org.meshtastic.core.repository.PacketRepository
class DesktopMessageQueue(
private val packetRepository: PacketRepository,
private val radioController: RadioController,
+ dispatchers: CoroutineDispatchers,
) : MessageQueue {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun enqueue(packetId: Int) {
scope.launch {

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
index 5e223ed674..b0761522de 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
@@ -24,15 +24,18 @@ import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.feature.node.compass.PhoneLocationState
+/** No-op [CompassHeadingProvider] β€” desktop has no compass sensor. */
class NoopCompassHeadingProvider : CompassHeadingProvider {
override fun headingUpdates(): Flow<HeadingState> = flowOf(HeadingState(hasSensor = false))
}
+/** No-op [PhoneLocationProvider] β€” desktop has no GPS provider. */
class NoopPhoneLocationProvider : PhoneLocationProvider {
override fun locationUpdates(): Flow<PhoneLocationState> =
flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
}
+/** No-op [MagneticFieldProvider] β€” always returns zero declination. */
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
}

diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
index 00b2e82c7a..a55bf902f8 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
@@ -31,7 +31,10 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
-/** Desktop main screen β€” uses shared navigation components. */
+/**
+ * Desktop main screen β€” assembles the shared [MeshtasticAppShell], [MeshtasticNavigationSuite], and
+ * [MeshtasticNavDisplay] with the desktop-specific [desktopNavGraph] entry provider.
+ */
@Composable
fun DesktopMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack) {
val backStack = multiBackstack.activeBackStack

diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 80eed61c5b..f2887d98ac 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -42,7 +42,6 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.ui)
- implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 230e6533f1..2c9978463c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,7 +12,6 @@ glance = "1.2.0-rc01"
lifecycle = "2.10.0"
jetbrains-lifecycle = "2.11.0-alpha03"
navigation3 = "1.1.0-rc01"
-navigationevent = "1.1.0-alpha01"
paging = "3.4.2"
room = "3.0.0-alpha03"
koin = "4.2.1"
@@ -112,7 +111,6 @@ jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecyc
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
-jetbrains-navigationevent-compose = { module = "org.jetbrains.androidx.navigationevent:navigationevent-compose", version.ref = "navigationevent" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" }

Served by rngit 1.5.0 - Generated in 0.4s